Skip to content

Mesh: let procedural geometry supply per-vertex colours - #1625

Merged
obiot merged 1 commit into
masterfrom
feat/mesh-vertex-colors
Aug 31, 2026
Merged

Mesh: let procedural geometry supply per-vertex colours#1625
obiot merged 1 commit into
masterfrom
feat/mesh-vertex-colors

Conversation

@obiot

@obiot obiot commented Aug 31, 2026

Copy link
Copy Markdown
Member

Closes #1624.

The gap

Both mesh batchers already write a per-vertex colour into the vertex buffer as aColor, falling back to opaque white when the array is absent — WebGL at mesh_batcher.js:1447, WebGPU at :766. So the rendering side was already done, on both backends.

What was missing was any way to supply it. mesh.vertexColors is only ever constructed at mesh.js:755, for a multi-material OBJ with a bound MTL, so each material group's Kd bakes onto its own vertices and the model draws in one call. No settings.vertexColors, no setter, no @param — a mesh you built yourself could not reach it.

That matters because tint is per object. A terrain built as one mesh can be tinted whole or not at all, so there is no way to fade its far end toward the sky, or darken a crease. The workaround is to split the mesh, which defeats the single draw call.

The API

Four additions to renderable/mesh.js. Neither batcher changes.

// at construction — packed RGBA8, or one Color per vertex
new Mesh(x, y, { vertices, uvs, indices, vertexColors });

// or per vertex, mirroring InstancedMesh#setInstanceColor
mesh.setVertexColor(index, color);
  • settings.vertexColors takes a Uint32Array (the form the batchers read — passed through untouched) or a Color[]/number[], packed on the way in. An explicit value wins over the multi-material bake, the same precedence settings.normals has over an OBJ's own normals.
  • setVertexColor(index, color) matches setInstanceColor's argument order and its silent out-of-range behaviour, and lazily creates a white array on first use.
  • Both reuse needsUpdate rather than adding a flag. This is load-bearing, not cosmetic: it bumps _geometryVersion, which the retained Camera3d path compares (mesh_batcher.js:467), so a colour written without it would apply on the immediate path and silently not on the retained one.
  • A length mismatch throws, naming both counts. A short array leaves the tail of the mesh reading whatever the buffer held, which gets debugged as a lighting bug rather than a length one.

Presence is the opt-in — no enabling flag, since the batcher already defaults to white. (Other libraries gate this behind a material boolean defaulting to false, which is a reliable "why is my mesh white" trap. Not copied.)

A defect found by writing the first test

The test compared mesh.vertexColors[i] against color.toUint32() and failed: 4278255360 vs -16711936.

Color#toUint32() ends its packing with |, which yields a signed int32 — so a method named toUint32, documented as returning "a Uint32 ARGB representation", returned a negative number for any colour with alpha at or above 0.5. Every consumer inside the engine writes it into a Uint32Array or a shader attribute where the bit pattern is identical, so nothing rendered wrong. What broke was reading it back, comparing it, or printing it.

The existing tests knew. All four had the correct expectation commented out:

//expect(uint32).toEqual(0xFF00FF00);
// jasmine test the value as signed int32
expect(uint32).toEqual(-16711936);

One is named "should return an unsigned 32-bit ARGB value" while asserting -65536. Fixed with >>> 0, and those four assertions restored to the values that were commented out.

Tests

13 new, covering both construction forms, the throw and its message, lazy array creation leaving other vertices white, the needsUpdate bump, out-of-range being a no-op that doesn't allocate, alpha carried through, plus round-trip and signedness coverage on toUint32.

Full suite: 6,444 passing, 264 files. eslint 0 errors, biome clean, tsc clean, typedoc 0 errors.

Also

  • melonjs-3d gains a Colouring a mesh section: the four levels (object tint / per vertex / per material / per instance), the "tint is per object" trap, the needsUpdate protocol, that it multiplies the lit result so it behaves as albedo rather than an emissive override, and three symptom rows.
  • Version to 20.4.0, with .claude-plugin/plugin.json moved in step (the release guard added in Agent skills, an llms.txt API index, and the defects verifying them uncovered #1620 requires it).

Built to be consumed: the terrain in a 3D example, one procedural mesh, now fades into the sky with distance and darkens in the crease. That is the thing tint could not express.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N

Both batchers already wrote a per-vertex `aColor` — WebGL at
mesh_batcher.js:1447, WebGPU at :766 — falling back to opaque white when the
array is absent. What was missing was any way to supply it: `mesh.vertexColors`
was only ever built internally, at mesh.js:755, for a multi-material OBJ with a
bound MTL. A mesh you built yourself could not reach it.

That matters because `tint` is per OBJECT. A terrain built as one mesh can be
tinted whole or not at all, so there is no way to fade its far end toward the
sky or darken a crease — the workaround is to split the mesh, which defeats the
single draw call. Per-vertex colour is also how you fake aerial perspective and
ambient occlusion without a shader, which is worth having while the engine has
no distance fog (#1622).

- `settings.vertexColors` takes packed RGBA8 (`Uint32Array`, the form the
  batchers read, passed through untouched) or one `Color` per vertex. An
  explicit value wins over the multi-material bake, the same precedence
  `settings.normals` has over an OBJ's own normals.
- `setVertexColor(index, color)` mirrors `InstancedMesh#setInstanceColor` —
  same argument order, same silent out-of-range behaviour.
- Both reuse the existing `needsUpdate` signal rather than adding a flag. That
  is load-bearing: it bumps `_geometryVersion`, which the retained Camera3d
  path compares, so a colour written without it would apply on the immediate
  path and silently not on the retained one. `setVertexColor` bumps it for you.
- A length that does not match `vertexCount` throws, naming both counts. A
  short array would leave the tail of the mesh reading whatever the buffer
  held, which gets debugged as a lighting bug rather than a length one.

Neither batcher changes.

Writing the first test turned up a real defect in `Color#toUint32()`: the
packing ends in `|`, which yields a SIGNED int32, so a method named `toUint32`
and documented as returning "a Uint32 ARGB representation" handed back
-16711936 for green. Every consumer inside the engine writes it into a
`Uint32Array` or a shader attribute where the bit pattern is identical, so
nothing rendered wrong — what broke was reading it back, comparing it, or
printing it. Fixed with `>>> 0`.

The four existing tests knew: each had the correct expectation commented out
with a note that the value came back signed, and one is named "should return an
unsigned 32-bit ARGB value" while asserting -65536. Those assertions are
restored to the values that were commented out.

Closes #1624

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI lite review requested due to automatic review settings August 31, 2026 05:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit b716922 into master Aug 31, 2026
6 checks passed
@obiot
obiot deleted the feat/mesh-vertex-colors branch August 31, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mesh: no way to supply vertexColors for procedural geometry

2 participants